"""Shopify synchronization endpoints."""

from __future__ import annotations

import json
from typing import Dict, Optional, Tuple, Any
import os
import copy
import re
from datetime import timedelta
from urllib.parse import quote

import frappe
from frappe import _, _dict
from dateutil import parser as date_parser

from frappe.utils import get_datetime, now_datetime, cint, getdate, flt
from frappe.defaults import get_global_default
from frappe.desk.doctype.notification_log.notification_log import (
    enqueue_create_notification as _enqueue_notification,
)

from ..utils.shopify_client import ShopifyClient, ShopifyClientError
from .api import create_yunexpress_order, WDT_WAREHOUSE_CODE, build_wdt_delivery_data, cancel_yunexpress_order, wdt
from . import api_shop as shop_api

DEFAULT_ORDER_SINCE = "2000-01-01T00:00:00Z"
DEFAULT_ORDER_BATCH_SIZE = 50
DEFAULT_ORDER_MAX_PAGES = 1

DEFAULT_SKU_SINCE = "2000-01-01T00:00:00Z"
DEFAULT_SKU_BATCH_SIZE = 50
DEFAULT_SKU_MAX_PAGES = 1

CHECKPOINT_SINCE_DRIFT_SECONDS = 60  # 时间戳防抖：下次启动回退秒数，避免边界漏单


INTERNAL_TRACKING_BASE_URL = "https://oms.flowa.global/tms_tracking"
USPS_TRACK_URL = "http://tools.usps.com/go/TrackConfirmAction?tLabels="
EVRI_TRACK_URL = "https://www.evri.com/track/parcel/"
DHL_TRACK_URL = (
    "https://www.dhl.com/global-en/home/tracking/tracking-express.html?submit=1&tracking-id="
)
GENERIC_17TRACK_URL = "https://www.17track.net/en?nums="

COUNTRY_CODE_ALIASES = {
    "UNITED STATES": "US",
    "UNITED STATES OF AMERICA": "US",
    "USA": "US",
    "AMERICA": "US",
    "UNITED KINGDOM": "GB",
    "GREAT BRITAIN": "GB",
    "ENGLAND": "GB",
    "UK": "GB",
    "GERMANY": "DE",
    "DEUTSCHLAND": "DE",
    "FRANCE": "FR",
    "SPAIN": "ES",
    "ESPANA": "ES",
    "NETHERLANDS": "NL",
    "THE NETHERLANDS": "NL",
    "HOLLAND": "NL",
}

COUNTRY_CODE_ALIAS_LOOKUP = {
    key.lower(): value for key, value in COUNTRY_CODE_ALIASES.items()
}

BASE_SPECIAL_TRACKING_RULES = {
    "US": {
        "carrier": "USPS",
        "url_template": USPS_TRACK_URL,
        "number_fields": ["custom_delivery_no", "custom_delivery_order_no"],
    },
    "GB": {
        "carrier": "Evri",
        "url_template": EVRI_TRACK_URL,
        "number_fields": ["custom_delivery_no", "custom_delivery_order_no"],
    },
    "DE": {
        "carrier": "DHL",
        "url_template": DHL_TRACK_URL,
        "number_fields": ["custom_delivery_no", "custom_delivery_order_no"],
    },
    "FR": {
        "carrier": "Colisprive",
        "url_template": GENERIC_17TRACK_URL,
        "number_fields": ["custom_delivery_no", "custom_delivery_order_no"],
    },
    "ES": {
        "carrier": "CTT",
        "url_template": GENERIC_17TRACK_URL,
        "number_fields": ["custom_delivery_no", "custom_delivery_order_no"],
    },
}

_RULE_YTYCPREC = copy.deepcopy(BASE_SPECIAL_TRACKING_RULES)
_RULE_YTYCPREG = copy.deepcopy(BASE_SPECIAL_TRACKING_RULES)
_RULE_YTYCPREG["NL"] = {
    "carrier": "DHL",
    "url_template": DHL_TRACK_URL,
    "number_fields": ["custom_delivery_no", "custom_delivery_order_no"],
}

SPECIAL_TRACKING_RULES = {
    "YTYCPREC": _RULE_YTYCPREC,
    "YTYCPREG": _RULE_YTYCPREG,
}

RULE_NUMBER_FIELDS_DEFAULT = ["custom_delivery_no", "custom_delivery_order_no"]
DEFAULT_TRACKING_FIELDS = ["custom_delivery_order_no", "custom_delivery_no"]
DEFAULT_TRACKING_CARRIER = "Flowa"


def _trim(value: object) -> str:
    if value is None:
        return ""
    try:
        return str(value).strip()
    except Exception:
        return ""


def _normalize_upper(value: object) -> str:
    trimmed = _trim(value)
    return trimmed.upper() if trimmed else ""


def _normalize_lower(value: object) -> str:
    trimmed = _trim(value)
    return trimmed.lower() if trimmed else ""


def _ensure_fg_prefix(number: str) -> str:
    value = _trim(number)
    if not value:
        return ""
    if len(value) >= 2 and value[:2].upper() == "FG":
        return value
    if len(value) <= 2:
        return f"FG{value}"
    return f"FG{value[2:]}"


def _build_internal_tracking_url(number: str) -> str:
    num = _trim(number)
    if num:
        # 注意：后端路由只在 /tms_tracking 生效，带斜杠 /tms_tracking/ 会被 Nginx/站点配置重定向到 127.0.0.1
        # 因此这里拼接为 '?num=' 而不在前面加 '/'
        return f"{INTERNAL_TRACKING_BASE_URL}?num={quote(num)}"
    return INTERNAL_TRACKING_BASE_URL


def _pick_tracking_number(doc, fields: list[str]) -> str:
    if not fields:
        return ""
    for field in fields:
        if not field:
            continue
        value = None
        try:
            value = doc.get(field)
        except Exception:
            value = getattr(doc, field, None)
        trimmed = _trim(value)
        if trimmed:
            return trimmed
    return ""


def _coerce_country_code(value) -> str:
    raw = _trim(value)
    if not raw:
        return ""
    upper = raw.upper()
    if len(upper) == 2:
        return upper
    alias = COUNTRY_CODE_ALIAS_LOOKUP.get(raw.lower())
    if alias:
        return alias
    try:
        code = frappe.db.get_value("Country", raw, "code")
        if code:
            return _normalize_upper(code)
    except Exception:
        pass
    return ""


def _sanitize_shopify_order_name(order_name: Optional[str]) -> Optional[str]:
    if not order_name:
        return None
    cleaned = order_name.strip()
    if not cleaned:
        return None
    cleaned = cleaned.lstrip("#").strip()
    if not cleaned:
        return None
    # Replace unsupported characters with hyphen
    cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", cleaned)
    cleaned = cleaned.strip("-_")
    if not cleaned:
        return None
    # Trim to ERPNext docname length (140 chars)
    cleaned = cleaned[:140]

    # Ensure uniqueness by appending suffix when necessary
    base = cleaned
    idx = 1
    while frappe.db.exists("Sales Order", cleaned):
        suffix = f"-{idx}"
        # Ensure we stay within length limit when adding suffix
        cleaned = f"{base[:140 - len(suffix)]}{suffix}"
        idx += 1
    return cleaned

_RULE_YTYCPREC = copy.deepcopy(BASE_SPECIAL_TRACKING_RULES)
_RULE_YTYCPREG = copy.deepcopy(BASE_SPECIAL_TRACKING_RULES)

def _resolve_country_code(sales_order) -> str:
    raw_value = getattr(sales_order, "custom_country", None)
    trimmed = _trim(raw_value)

    if trimmed:
        code = _coerce_country_code(trimmed)
        if code:
            return code
        so_name = getattr(sales_order, "name", None) or ""
        frappe.logger().error(
            "[Shopify Fulfillment] 无法解析国家代码 | order=%s | raw=%r | trimmed=%r",
            so_name,
            raw_value,
            trimmed,
        )
        raise frappe.ValidationError(
            _("Sales Order {0} 的目的国家 {1} 无法识别，请检查 Country 主数据").format(
                so_name, trimmed
            )
        )

    so_name = getattr(sales_order, "name", None) or ""
    frappe.logger().error(
        "[Shopify Fulfillment] 缺少目的国家 | order=%s | raw=%r", so_name, raw_value
    )
    raise frappe.ValidationError(
        _("Sales Order {0} 缺少目的国家(custom_country)").format(so_name)
    )


def _resolve_logistics_product_code(sales_order) -> str:
    candidates = [
        getattr(sales_order, "custom_yt_product_code", None),
        getattr(sales_order, "custom_product_code", None),
        getattr(sales_order, "custom_logistics_product", None),
    ]
    for candidate in candidates:
        code = _normalize_upper(candidate)
        if code:
            return code
    return ""


def _resolve_shopify_tracking_details(sales_order) -> Dict[str, str]:
    product_code = _resolve_logistics_product_code(sales_order)
    country_code = _resolve_country_code(sales_order)
    rule = SPECIAL_TRACKING_RULES.get(product_code, {}).get(country_code)

    tracking_number = ""
    tracking_url = ""
    carrier = ""

    if rule:
        number_fields = rule.get("number_fields") or RULE_NUMBER_FIELDS_DEFAULT
        tracking_number = _pick_tracking_number(sales_order, number_fields)
        if not tracking_number:
            tracking_number = _pick_tracking_number(
                sales_order, RULE_NUMBER_FIELDS_DEFAULT
            )
        carrier = _trim(rule.get("carrier"))
        template = _trim(rule.get("url_template"))
        if template:
            tracking_url = (
                f"{template}{quote(tracking_number)}" if tracking_number else template
            )
    else:
        tracking_number = _ensure_fg_prefix(
            _pick_tracking_number(sales_order, DEFAULT_TRACKING_FIELDS)
        )

    if not tracking_number:
        tracking_number = _ensure_fg_prefix(
            _pick_tracking_number(sales_order, RULE_NUMBER_FIELDS_DEFAULT)
        )

    if not carrier:
        carrier = DEFAULT_TRACKING_CARRIER

    # Shopify 侧 Tracking URL 一律使用内部 Flowa 跟踪地址，不再回退到云途面单链接
    if not tracking_url:
        tracking_url = _build_internal_tracking_url(tracking_number)

    return {
        "tracking_number": tracking_number,
        "tracking_url": tracking_url,
        "carrier": carrier,
        "country_code": country_code,
        "logistics_product": product_code,
    }


class MissingSKUError(Exception):
    """Raised when an order references Item codes that are not available in ERP."""



def _get_first_existing_field(meta, names: list[str]) -> Optional[str]:
    if not meta:
        return None
    for fname in names:
        try:
            if meta.has_field(fname):
                return fname
        except Exception:
            pass
    return None


def _debug_on() -> bool:
    """Global toggle for verbose Shopify sync logging.
    Enable by setting shopify_sync_debug = 1 in site config.
    """
    return bool(getattr(frappe.conf, "shopify_sync_debug", False))


def _log_json_chunks(tag: str, payload, chunk_size: int = 2000) -> None:
    """Log a potentially large JSON payload in fixed-size chunks without truncation.
    No try/except here – assumes payload is JSON-serializable (dict/list/str/number).
    """
    s = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
    total = len(s)
    parts = (total + chunk_size - 1) // chunk_size
    for idx in range(parts):
        start = idx * chunk_size
        end = min(start + chunk_size, total)
        part = s[start:end]
        frappe.logger().error(f"{tag}[{idx + 1}/{parts}]: {part}")



PICK_RULE_PARENT_DT = "shop picker rule"
PICK_RULE_CHILD_DT = "shop pick rule-table"


def _get_pick_rules_for_shop(shop_name: str) -> list[dict]:
    """Return all active pick-rule rows for given usershop.
    仅按店铺过滤，不做匹配与打分；调用方可自行筛选。
    字段包含: des_country, sku, logistick_product, sender_warehouse, auto_mode
    """
    try:
        parents = frappe.get_all(PICK_RULE_PARENT_DT, filters={"link_zbeg": shop_name}, pluck="name", limit_page_length=0)
        if not parents:
            return []
        rows = frappe.get_all(
            PICK_RULE_CHILD_DT,
            filters={"parent": ["in", parents], "is_active": 1},
            fields=[
                "name",
                "des_country",
                "logistick_product",
                "auto_mode",
                "warhouse",
                "parent",
            ],
            order_by="modified desc",
            limit_page_length=1000,
        )
        return rows or []
    except Exception:
        return []


def _parse_logistics_product(value: Optional[str]) -> tuple[Optional[str], Optional[str]]:
    """Split logistics product string into (code, name).
    Accepts formats like "CODE_Name"; when no underscore or invalid, treat entire as code.
    Returns (code, name) where name may be None.
    """
    if not value:
        return None, None
    try:
        s = str(value).strip()
        if not s:
            return None, None
        if "_" in s:
            code, name = s.split("_", 1)
            code = code.strip()
            name = name.strip()
            return (code or None), (name or None)
        return s, None
    except Exception:
        return str(value).strip() or None, None


def _select_pick_rule_for_order_from_rules(shop_name: str, payload: Dict, rules: list[dict]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
    """根据店铺与当前订单信息选择一条拣货规则。
    - 国家: shippingAddress.countryCodeV2
    - 仅按国家匹配；表仅保留 des_country/logistick_product/auto_mode/warehouse
    返回: (warehouse, product_code, auto_mode)
    未命中时返回 (None, None, None)
    """
    try:
        dest_country = (payload.get("shippingAddress") or {}).get("countryCodeV2")
        # 仅按国家匹配（SKU 不参与）
        if not rules:
            try:
                frappe.logger().error(
                    f"[PickRule][Select] no rules: shop={shop_name} country={dest_country}"
                )
            except Exception:
                pass
            return None, None, None

        def norm(x: Optional[str]) -> str:
            return (str(x or "").strip())

        nd = norm((payload.get("shippingAddress") or {}).get("countryCodeV2"))
        exact = [r for r in rules if norm(r.get("des_country")) == nd and nd]
        defaults = [r for r in rules if norm(r.get("des_country")) in ("", "-")]

        chosen = (exact[0] if exact else (defaults[0] if defaults else None))
        if not chosen:
            try:
                frappe.logger().error(
                    f"[PickRule][Select] no match: shop={shop_name} country={nd} rules_count={len(rules)}"
                )
            except Exception:
                pass
            return None, None, None

        # 严格使用字段名：warhouse
        wh = (chosen.get("warhouse") or "").strip() or None
        prod_raw = (chosen.get("logistick_product") or "").strip() or None
        prod_code, _prod_name = _parse_logistics_product(prod_raw)
        pick_mode = (chosen.get("auto_mode") or "").strip() or None
        try:
            frappe.logger().error(
                f"[PickRule][Select] match: shop={shop_name} country={nd} -> wh={wh} product={prod_code} mode={pick_mode}; chosen={{'des_country': {chosen.get('des_country')}, 'warhouse': {chosen.get('warhouse')}, 'logistick_product': {chosen.get('logistick_product')}, 'auto_mode': {chosen.get('auto_mode')}}}"
            )
        except Exception:
            pass
        return wh, prod_code, pick_mode
    except Exception:
        return None, None, None


def _assert_stock_available(item_code: str, warehouse: Optional[str], qty: float) -> None:
    if not warehouse:
        return
    try:
        data = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, ["actual_qty"], as_dict=True)
        actual = flt((data or {}).get("actual_qty") or 0)
        if actual <= 0:
            raise frappe.ValidationError(f"仓库 {warehouse} 中商品 {item_code} 无库存，无法发货")
        # 若需严格校验数量可改为 actual < qty
    except frappe.DoesNotExistError:
        raise frappe.ValidationError(f"仓库 {warehouse} 中商品 {item_code} 无库存记录(Bin)")
    except Exception as e:
        # 保守处理：查询异常时不阻断
        frappe.logger().warning(f"库存校验异常: {e}")


def _get_allowed_warehouses(shop_name: str) -> list:
    """获取店铺允许发货的仓库列表 - 必须配置"""
    try:
        shop_doc = frappe.get_doc("usershop", shop_name)
        allowed_warehouses = getattr(shop_doc, 'allowed_warehouses', '[]')
                
        if isinstance(allowed_warehouses, str):
            if not allowed_warehouses.strip():
                frappe.logger().error(f"[Shopify] 店铺 {shop_name} 未配置允许的仓库列表 (空字符串)")
                return []
            parsed_warehouses = json.loads(allowed_warehouses)
            return parsed_warehouses
        
        if not allowed_warehouses:
            frappe.logger().error(f"[Shopify] 店铺 {shop_name} 未配置允许的仓库列表 (空值)")
            return []
            
        return allowed_warehouses
    except json.JSONDecodeError as e:
        frappe.logger().error(f"[Shopify] 店铺 {shop_name} 仓库配置格式错误: {str(e)}, 原始值: {allowed_warehouses}")
        return []
    except Exception as e:
        frappe.logger().error(f"[Shopify] 获取允许仓库列表失败: {str(e)}")
        return []


def _validate_shop_config_for_import(shop_name: str, allowed_warehouses: list[str]) -> None:
    """校验导入前置配置：
    - usershop.allowed_warehouses 必须存在且非空
    - 分拣/拣货规则表需存在且有"激活"行，并配置了 warhouse 与 logistick_product
    """
    # 1) 校验允许仓库（Shopify Location 名称集合）
    # 这里只要求为非空数组，且元素为非空字符串；不与 ERP 的 Warehouse 做存在性/公司一致性比对
    if not allowed_warehouses:
        raise frappe.ValidationError(
            f"店铺 {shop_name} 未配置 allowed_warehouses（Shopify地点名的 JSON 数组）"
        )
    if not all(str(x).strip() for x in allowed_warehouses):
        raise frappe.ValidationError(
            f"店铺 {shop_name} 的 allowed_warehouses 含有空值，请修正为有效的 Shopify 地点名"
        )

    # 2) 校验分拣规则（按店铺）：至少一条激活记录且配置了仓库与物流产品
    parents = frappe.get_all(PICK_RULE_PARENT_DT, filters={"link_zbeg": shop_name}, pluck="name", limit_page_length=1)
    if not parents:
        raise frappe.ValidationError(
            f"店铺 {shop_name} 未建立分拣规则({PICK_RULE_PARENT_DT})，请先创建并关联"
        )
    child_rows = frappe.get_all(
        PICK_RULE_CHILD_DT,
        filters={"parent": ["in", parents], "is_active": 1},
        fields=["logistick_product", "warhouse"],
        limit_page_length=500,
    )
    if not child_rows:
        raise frappe.ValidationError(
            f"分拣/拣货规则({PICK_RULE_CHILD_DT})无激活记录，请先维护"
        )
    valid_rows = [r for r in child_rows if (str(r.get("logistick_product") or "").strip() and str(r.get("warhouse") or "").strip())]
    if not valid_rows:
        raise frappe.ValidationError(
            f"分拣/拣货规则({PICK_RULE_CHILD_DT})未配置有效记录（需同时填写 warhouse 与 logistick_product），请先维护"
        )


def _get_fulfillment_location(order: Dict) -> str:
    """从订单中提取履约仓库名称"""
    fulfillment_orders = order.get("fulfillmentOrders", {}).get("edges", [])
    
    if fulfillment_orders:
        assigned = fulfillment_orders[0].get("node", {}).get("assignedLocation", {})
        
        if assigned:
            # 直接从 assignedLocation.name 获取仓库名称
            location_name = assigned.get("name", "")
            return location_name
        else:
            frappe.logger().error(f"[Shopify] 订单 {order.get('name', order.get('id'))} assignedLocation 为空")
    else:
        frappe.logger().error(f"[Shopify] 订单 {order.get('name', order.get('id'))} fulfillment_orders 为空")
    
    return ""

def _stringify_payload(data: Dict) -> str:
    return json.dumps(data, ensure_ascii=False)

def _coerce_datetime(value: Optional[str]) -> Optional[str]:
    if not value:
        return None

    attempts = [value]
    if value.endswith("+00:00"):
        attempts.append(value[:-6])
    if "T" in value:
        attempts.append(value.replace("T", " "))

    for candidate in attempts:
        try:
            dt = get_datetime(candidate)
            if dt:
                return dt.strftime("%Y-%m-%d %H:%M:%S")
        except Exception:
            try:
                dt = date_parser.parse(candidate)
                if dt:
                    return dt.strftime("%Y-%m-%d %H:%M:%S")
            except Exception:
                continue
    return None


def _extract_order_fields(shop_name: str, order: Dict, allowed_warehouses: list = None) -> Dict[str, object]:

    shipping = order.get("shippingAddress") or {}

    total_price_info = (
        order.get("totalPriceSet", {}).get("shopMoney", {}) if order else {}
    )

    # 调试：检查 shopify_order_id 的值
    shopify_id = order.get("id")
    
    # 履约仓库名称（若存在）
    try:
        fulfillment_location = _get_fulfillment_location(order)
    except Exception:
        fulfillment_location = None

    values: Dict[str, object] = {
        "title": order.get("name") or order.get("id"),
        "custom_shop_name": shop_name,
        "custom_shopify_order_id": shopify_id,
        "raw_payload": _stringify_payload(order),
        "fulfillment_location": fulfillment_location,
    }

    for key, extractor in (
        ("order_number", lambda: order.get("name")),
        ("order_created_at", lambda: _coerce_datetime(order.get("createdAt"))),
        ("destination_country", lambda: shipping.get("countryCodeV2")),
        (
            "total_price",
            lambda: total_price_info.get("amount") if total_price_info else None,
        ),
        (
            "currency",
            lambda: total_price_info.get("currencyCode") if total_price_info else None,
        ),
        (
            "customer_email",
            lambda: (order.get("customer") or {}).get("email"),
        ),
        (
            "customer_name",
            lambda: (order.get("customer") or {}).get("displayName"),
        ),
        (
            "remarks",
            lambda: f"履约仓库: {fulfillment_location}"
            if fulfillment_location
            else "履约仓库: 未指定",
        ),
    ):
        try:
            value = extractor()
        except Exception:
            value = None
        if value not in (None, ""):
            values[key] = value

    return values


# NOTE: legacy upsert helpers for Shopify staging docs have been removed


def _build_order_query(since: Optional[str] = None) -> Optional[str]:
    if not since:
        return None
    # Shopify GraphQL requires ISO8601 format; caller should pass ISO string.
    return f"created_at:>={since}"

@frappe.whitelist()
def sync_shopify_orders(
    shop_name: str,
    batch_size: int = DEFAULT_ORDER_BATCH_SIZE,
    cursor: Optional[str] = None,
    since: Optional[str] = None,
    max_pages: int = DEFAULT_ORDER_MAX_PAGES,
) -> Dict[str, object]:
    """同步Shopify订单到 staging 表。
    Args:
        shop_name: usershop.name
        batch_size: 每次请求数量，最大 50
        cursor: Shopify GraphQL 游标，用于增量翻页
        since: ISO8601 字符串，限定创建时间下限
        max_pages: 允许抓取的最大分页数，避免全量时长时间阻塞
    """

    frappe.logger().info(
        "[Shopify][Orders] 请求开始: shop=%s, batch_size=%s, cursor=%s, since=%s, max_pages=%s",
        shop_name,
        batch_size,
        cursor,
        since,
        max_pages,
    )

    # 先做前置配置校验：allowed_warehouses + 分拣规则是否存在有效激活行
    try:
        _validate_shop_config_for_import(shop_name, _get_allowed_warehouses(shop_name))
    except Exception as e:
        # 立即终止任务，抛出明确提示
        raise

    fetched = 0
    created = 0
    updated = 0
    skipped_warehouse = 0
    skipped_unpaid = 0
    skipped_fulfilled = 0

    try:
        client = ShopifyClient(shop_name)
    except ShopifyClientError as exc:
        frappe.logger().error(f"Shopify配置错误: {exc}")
        return {"success": False, "message": str(exc)}

    # 一次性获取允许的仓库列表（Shopify Location 名称），避免在循环中重复查询
    allowed_warehouses = _get_allowed_warehouses(shop_name)
    # 强制要求仓库过滤必须配置
    if not allowed_warehouses:
        frappe.throw(
            msg=f"店铺 {shop_name} 未配置 allowed_warehouses（JSON 数组），无法同步订单。",
            exc=frappe.ValidationError,
        )
    frappe.logger().error(f"[Shopify] 店铺 {shop_name} Shopify地点配置: {allowed_warehouses}")

    batch_size = cint(batch_size) or DEFAULT_ORDER_BATCH_SIZE
    max_pages = cint(max_pages) or DEFAULT_ORDER_MAX_PAGES

    effective_since = since or DEFAULT_ORDER_SINCE
    query = _build_order_query(effective_since)
    current_cursor = cursor
    has_next = True
    pages = 0
    any_failed = False  # 是否发生导致需重试的失败（阻止推进游标/检查点）

    try:
        while has_next and pages < max_pages:
            orders, next_cursor, has_next_page = client.fetch_orders(
                first=min(batch_size, 250),
                after=current_cursor,
                query=query,
            )

            # 打印本页每个订单（完整 JSON）与每行（完整 JSON）- 仅调试开启时
            if _debug_on():
                for ord0 in (orders or []):
                    _log_json_chunks("[Shopify][OrderJSON]", ord0)
                    loc_map = _get_line_item_location_map(ord0)
                    for li in (ord0.get("lineItems") or {}).get("nodes", []):
                        # 将行补充推断到的仓库，便于核对
                        li_aug = dict(li)
                        li_aug["__resolved_location"] = loc_map.get(str(li.get("id")))
                        _log_json_chunks("[Shopify][OrderItemJSON]", li_aug)

            if not orders:
                has_next = False
                break
            # 本页导入是否出现失败（用于阻止游标推进）
            page_failed = False
            for order in orders:
                # 主循环内做过滤：跳过草稿与不在允许仓库范围内的订单
                try:
                    disp_fin = (order.get("displayFinancialStatus") or "").upper()
                except Exception:
                    disp_fin = ""
                if disp_fin != "PAID":
                    skipped_unpaid += 1
                    continue

                order_locations = _get_fulfillment_locations(order)
                if allowed_warehouses:
                    if not order_locations or not (set(allowed_warehouses) & order_locations):
                        skipped_warehouse += 1
                        continue

                # 直接导入业务单据：创建 Sales Order（不写 Shopify Order 中间表）
                try:
                    so_name = import_sales_order_from_payload(
                        shop_name=shop_name,
                        order=order,
                        warehouse="",  # 仓库由拣货规则 warhouse 提供
                        auto_dispatch=False,
                        allowed_locations=set(allowed_warehouses or []),
                        preloaded_rules=_get_pick_rules_for_shop(shop_name),
                    )
                    fetched += 1
                    if so_name:
                        created += 1
                        try:
                            oid = order.get("id") or order.get("name")
                            frappe.logger().info(
                                f"[Shopify][Orders] 创建Sales Order: {so_name} <- {oid}"
                            )
                        except Exception:
                            pass
                except MissingSKUError as e:
                    fetched += 1
                    page_failed = True  # 缺少SKU也视为失败，不推进游标
                    try:
                        oid = order.get("id") or order.get("name")
                        frappe.logger().error(
                            f"[Shopify][Orders] 缺少SKU，需人工处理后重试: order={oid} err={e}"
                        )
                    except Exception:
                        frappe.logger().error(
                            f"[Shopify][Orders] 缺少SKU，需人工处理后重试: {e}"
                        )
                except Exception as e:
                    fetched += 1
                    page_failed = True
                    try:
                        oid = order.get("id") or order.get("name")
                        frappe.logger().error(
                            f"[Shopify][Orders] 导入失败: order={oid} error={e}\n{frappe.get_traceback()}"
                        )
                        _notify_task(
                            subject="Shopify订单导入失败",
                            message=str(e),
                            shop_name=shop_name,
                            shopify_order_id=oid,
                            level="error",
                        )
                    except Exception:
                        frappe.logger().error(
                            f"[Shopify][Orders] 导入失败: {e}\n{frappe.get_traceback()}"
                        )

            # 若本页出现导入失败，不推进游标，退出循环以便下次重试本页
            if page_failed:
                any_failed = True
                has_next = False
                break

            current_cursor = next_cursor
            has_next = bool(has_next_page and next_cursor)
            pages += 1

        frappe.db.commit()

        message_parts = [_("同步订单完成: {0} 条, 新增 {1} 条, 更新 {2} 条").format(fetched, created, updated)]
        if skipped_warehouse > 0:
            message_parts.append(_("跳过仓库过滤: {0} 条").format(skipped_warehouse))
        if skipped_unpaid > 0:
            message_parts.append(_("跳过未支付: {0} 条").format(skipped_unpaid))
        if skipped_fulfilled > 0:
            message_parts.append(_("跳过已履约完成: {0} 条").format(skipped_fulfilled))
        
        return {
            "success": (not any_failed),
            "message": " | ".join(message_parts),
            "fetched": fetched,
            "created": created,
            "updated": updated,
            "skipped_warehouse": skipped_warehouse,
            "skipped_unpaid": skipped_unpaid,
            "skipped_fulfilled": skipped_fulfilled,
            "has_next": has_next,
            "next_cursor": current_cursor
        }

    except ShopifyClientError as exc:
        frappe.db.rollback()
        frappe.logger().error(
            "[Shopify][Orders] 异常: %s\n%s",
            exc,
            frappe.get_traceback(),
        )
        frappe.logger().error(f"Shopify订单同步失败: {frappe.get_traceback()}")
        return {
            "success": False,
            "message": str(exc),
            "error": str(exc),
        }
    except Exception as exc:
        frappe.db.rollback()
        frappe.logger().error(
            "[Shopify][Orders] 未知异常: %s\n%s",
            exc,
            frappe.get_traceback(),
        )
        frappe.logger().error(f"Shopify订单同步失败: {frappe.get_traceback()}")
        return {
            "success": False,
            "message": str(exc),
            "error": str(exc),
    }

def sync_order_wdt_yt(sales_order):
    """同步销售订单到云途并构建旺店通发货单。"""

    # 初始化变量，确保在异常处理中也能访问
    waybill_number = ""
    tracking_number = ""
    label_url = ""
    
    try:
        frappe.db.savepoint("sync_order_wdt")

        customer = frappe.get_doc("Customer", sales_order.customer)

        warehouse = sales_order.set_warehouse or frappe.db.get_single_value("Stock Settings", "default_warehouse")
        if not warehouse:
            raise frappe.ValidationError("销售订单缺少关联仓库，无法创建云途运单")

        company_doc = frappe.get_cached_doc("Company", sales_order.company)
        prefer_private = bool(getattr(company_doc, "custom_is_virtual_trace", False))

        warehouse_address = _get_warehouse_address(warehouse, prefer_private)
        receiver_info = _extract_shopify_shipping_address(sales_order)
        if not receiver_info:
            raise frappe.ValidationError("Shopify 订单缺少收货地址，无法创建云途运单")

        # 物流产品代码优先级：
        # 1) Sales Order 自定义字段 custom_yt_product_code（持久化）
        # 2) flags.yt_product_code（内存态）
        # 3) 若以上都无，则从保存的 payload + 规则再次解析
        # 4) 仍无则回退默认
        selected_product_code = None
        try:
            selected_product_code = getattr(sales_order, "custom_yt_product_code", None)
            if selected_product_code:
                selected_product_code = str(selected_product_code).strip()
        except Exception:
            selected_product_code = None

        yt_result = create_yunexpress_order(
            sales_order,
            receiver_info,
            warehouse_address,
            selected_product_code,
        )
        if not yt_result.get("success"):
            frappe.db.rollback(save_point="sync_order_wdt")
            raise frappe.ValidationError(f"云途订单创建失败: {yt_result.get('message')}")

        yt_data = yt_result.get("data", {})
        waybill_number = yt_data.get("waybill_number", "")
        tracking_number = yt_data.get("tracking_number", "")
        # 规范化：YT* → FG*
        try:
            if isinstance(tracking_number, str) and tracking_number[:2].upper() == "YT":
                tracking_number = "FG" + tracking_number[2:]
        except Exception:
            pass
        label_url = yt_data.get("label_url", "")

        if sales_order.meta.has_field("custom_yt_ret_data"):
            sales_order.db_set("custom_yt_ret_data", json.dumps(yt_data, ensure_ascii=False), update_modified=False)
        if sales_order.meta.has_field("custom_delivery_no") and tracking_number:
            sales_order.db_set("custom_delivery_no", tracking_number, update_modified=False)
        if sales_order.meta.has_field("custom_delivery_order_no") and waybill_number:
            sales_order.db_set("custom_delivery_order_no", waybill_number, update_modified=False)
        if sales_order.meta.has_field("custom_label_url") and label_url:
            sales_order.db_set("custom_label_url", label_url, update_modified=False)

        # 对于 Shopify 订单，使用从 Shopify 提取的收货地址信息
        # 不需要从 ERPNext 的 Address 表获取，因为收货地址信息已经在 receiver_info 中
        delivery_data = build_wdt_delivery_data(
            sales_order,
            shipping_address=receiver_info,  # 使用从 Shopify 提取的收货地址
            sender_address=warehouse_address,
            waybill_number=waybill_number,
            tracking_number=tracking_number,
            label_url=label_url,
            logistics_product_code=selected_product_code,
        )

        result = wdt.wdt_wms_deliveryorder_create(delivery_data)
        if isinstance(result, dict) and result.get("code") == "0":
            # 旺店通发货单创建成功，获取仓库订单号
            warehouse_order_no = result.get("data", {}).get("deliveryOrderNo") or result.get("deliveryOrderNo")
            if warehouse_order_no and sales_order.meta.has_field("custom_warehouse_order_no"):
                sales_order.db_set("custom_warehouse_order_no", warehouse_order_no, update_modified=False)
                frappe.logger().info(f"旺店通仓库订单号已设置: {warehouse_order_no}")
            return delivery_data

        # 旺店通发货单创建失败，需要撤销云途订单
        frappe.logger().warning(f"旺店通发货单创建失败，开始撤销云途订单: {waybill_number}")
        cancel_result = cancel_yunexpress_order(waybill_number)
        if not cancel_result.get("success"):
            frappe.logger().error(f"撤销云途订单失败: {cancel_result.get('message')}")
        
        frappe.db.rollback(save_point="sync_order_wdt")
        error_message = result.get("message") if isinstance(result, dict) else str(result)
        raise frappe.ValidationError(f"旺店通发货单创建失败: {error_message}")

    except Exception as e:

        # 如果云途订单已创建，需要撤销云途订单
        if len(waybill_number) > 3:
            frappe.logger().warning(f"发生异常，开始撤销云途订单: {waybill_number}")
            cancel_result = cancel_yunexpress_order(waybill_number)
            if not cancel_result.get("success"):
                frappe.logger().error(f"撤销云途订单失败: {cancel_result.get('message')}")
            else:
                frappe.logger().info(f"云途订单撤销成功: {waybill_number}")
        else:
            frappe.logger().info("云途订单未创建，无需撤销")
        
        frappe.db.rollback(save_point="sync_order_wdt")
        frappe.logger().error(f"构建旺店通发货单数据失败: {str(e)}")
        raise e

def _get_order_company(shop_name: Optional[str]) -> str:
    if shop_name:
        company = frappe.db.get_value("usershop", shop_name, "company")
        if company:
            return company

    company = get_global_default("company") or frappe.db.get_default("company")
    if company:
        return company

    fallback = frappe.get_all("Company", pluck="name", limit=1)
    if fallback:
        return fallback[0]

    raise frappe.ValidationError("No Company configured for Shopify import")


def _ensure_customer_exists(customer_name: str, company: Optional[str] = None) -> str:
    """Return a valid Customer name, creating it if missing.
    - Uses provided "company" if the Customer doctype has such a field.
    - Falls back to common defaults for group/territory.
    """
    if not customer_name or not str(customer_name).strip():
        raise frappe.ValidationError("customer_name is required")

    target_name = str(customer_name).strip()

    # Exact name exists
    try:
        if frappe.db.exists("Customer", target_name):
            return target_name
    except Exception:
        pass

    # Lookup by customer_name field
    existing = None
    try:
        existing = frappe.db.get_value("Customer", {"customer_name": target_name}, "name")
    except Exception:
        existing = None
    if existing:
        return existing

    # Create minimal Customer
    doc = frappe.new_doc("Customer")
    doc.customer_name = target_name
    if getattr(doc, "meta", None) and doc.meta.has_field("customer_type"):
        doc.customer_type = getattr(doc, "customer_type", None) or "Individual"

    # Defaults: Customer Group / Territory
    try:
        group = frappe.db.exists("Customer Group", "All Customer Groups")
        if not group:
            groups = frappe.get_all("Customer Group", pluck="name", limit=1)
            group = groups[0] if groups else None
        if group and doc.meta.has_field("customer_group"):
            doc.customer_group = group
    except Exception:
        pass

    try:
        terr = frappe.db.exists("Territory", "All Territories")
        if not terr:
            ters = frappe.get_all("Territory", pluck="name", limit=1)
            terr = ters[0] if ters else None
        if terr and doc.meta.has_field("territory"):
            doc.territory = terr
    except Exception:
        pass

    # Optional company binding if field exists
    if company and getattr(doc, "meta", None) and doc.meta.has_field("company"):
        doc.company = company

    doc.insert(ignore_permissions=True)
    return doc.name


def _set_optional_field(doc, fieldname: str, value) -> None:
    if value is None:
        return
    if hasattr(doc, "meta") and doc.meta.has_field(fieldname):
        doc.set(fieldname, value)


def _get_warehouse_address(warehouse_name: str, prefer_private: bool):
    warehouse_doc = frappe.get_cached_doc("Warehouse", warehouse_name)

    direct_address = getattr(warehouse_doc, "address", None)
    if direct_address:
        try:
            return frappe.get_cached_doc("Address", direct_address)
        except frappe.DoesNotExistError:
            frappe.logger().error(
                "[Shopify][Orders] 仓库 %s 指定的 address=%s 不存在",
                warehouse_name,
                direct_address,
            )

    linked_names = frappe.get_all(
        "Dynamic Link",
        filters={
            "link_doctype": "Warehouse",
            "link_name": warehouse_name,
            "parenttype": "Address",
        },
        pluck="parent",
    )

    if linked_names:
        priority = ["Private", "Shipping", "Warehouse"] if prefer_private else ["Shipping", "Warehouse", "Private"]

        all_linked = frappe.get_all(
            "Address",
            filters={"name": ["in", linked_names]},
            fields=["name", "address_type", "is_primary_address"],
            order_by="creation desc",
        )
        frappe.logger().debug(
            "[Shopify][Orders] 仓库地址查找: warehouse=%s prefer_private=%s linked_names=%s all_linked=%s",
            warehouse_name,
            prefer_private,
            linked_names,
            all_linked,
        )

        for address_type in priority:
            matched = frappe.get_all(
                "Address",
                filters={
                    "name": ["in", linked_names],
                    "address_type": address_type,
                },
                order_by="is_primary_address desc, creation desc",
                pluck="name",
                limit=1,
            )
            if matched:
                return frappe.get_cached_doc("Address", matched[0])

    fallback = frappe.get_all(
            "Address",
            filters={"name": ["in", linked_names]},
            order_by="is_primary_address desc, creation desc",
        pluck="name",
        limit=1,
    )
    if fallback:
            return frappe.get_cached_doc("Address", fallback[0])

    frappe.logger().warning(
        "[Shopify][Orders] 仓库地址缺失: warehouse=%s prefer_private=%s linked_names=%s",
        warehouse_name,
        prefer_private,
        linked_names,
    )
    raise frappe.ValidationError(
        frappe._("仓库 {0} 没有配置发货地址，请在仓库文档中添加地址").format(warehouse_name)
    )


def _extract_shopify_shipping_address(sales_order):
    payload = getattr(getattr(sales_order, "flags", None), "shopify_payload", None)
    if not payload:
        raw_data = getattr(sales_order, "custom_shop_raw_data", None)
        if raw_data:
            try:
                payload = json.loads(raw_data)
            except json.JSONDecodeError as exc:
                raise frappe.ValidationError(f"无法解析保存的 Shopify 原始数据: {exc}") from exc
    if not payload and getattr(sales_order, "custom_shopify_order_id", None):
        raw_payload = frappe.db.get_value("Shopify Order", {"shopify_order_id": sales_order.custom_shopify_order_id}, "raw_payload")
        if raw_payload:
            try:
                payload = json.loads(raw_payload)
            except json.JSONDecodeError as exc:
                raise frappe.ValidationError(f"无法解析 Shopify 订单 {sales_order.custom_shopify_order_id} 的原始数据: {exc}") from exc

    if not payload:
        raise frappe.ValidationError("缺少 Shopify 原始数据，无法解析收货地址")

    shipping = payload.get("shippingAddress") or payload.get("shipping_address")
    if not shipping:
        raise frappe.ValidationError("Shopify 订单缺少 shippingAddress")

    address_line1 = (shipping.get("address1") or shipping.get("addressLine1") or "").strip()
    city = (shipping.get("city") or "").strip()
    country = shipping.get("country") or shipping.get("countryCode") or shipping.get("countryCodeV2")
    postal_code = shipping.get("zip") or shipping.get("postalCode")

    missing_fields = []
    if not address_line1:
        missing_fields.append("address1")
    if not city:
        missing_fields.append("city")
    if not country:
        missing_fields.append("country")
    if not postal_code:
        missing_fields.append("postal_code")
    if missing_fields:
        raise frappe.ValidationError(
            "Shopify 收货地址缺少必填字段: " + ", ".join(missing_fields)
        )

    province = shipping.get("province") or shipping.get("provinceCode")
    area = shipping.get("county") or shipping.get("countyName")
    country_code = shipping.get("countryCode") or shipping.get("countryCodeV2") or country

    first_name = shipping.get("firstName") or ""
    last_name = shipping.get("lastName") or ""
    display_name = shipping.get("name") or " ".join(part for part in [first_name, last_name] if part)
    if not display_name:
        display_name = sales_order.customer_name or sales_order.customer

    customer_email = (
        payload.get("email")
        or (payload.get("customer") or {}).get("email")
        or (payload.get("contactEmail"))
    )

    return _dict({
        "address_title": display_name,
        "address_line1": address_line1,
        "address_line2": (shipping.get("address2") or shipping.get("addressLine2")),
        "city": city,
        "state": province,
        "country": country,
        "country_code": country_code,
        "pincode": postal_code,
        "phone": shipping.get("phone"),
        "email_id": customer_email,
        "county": area,
        "first_name": first_name,
        "last_name": last_name,
        "address_lines": [
            part
            for part in [address_line1, shipping.get("address2") or shipping.get("addressLine2")]
            if part
        ],
    })


def _get_items_warehouse_field(sales_order) -> Optional[str]:
    child_meta = sales_order.meta.get_field("items")
    if not child_meta or not child_meta.options:
        return None

    items_meta = frappe.get_meta(child_meta.options)
    if not items_meta:
        return None
    for fieldname in ("warehouse", "source_warehouse", "from_warehouse"):
        if items_meta.has_field(fieldname):
            return fieldname

    return "warehouse" if items_meta.has_field("warehouse") else None


@frappe.whitelist()
def update_shopify_fulfillment(
    sales_order_name: str,
    tracking_number: str,
    fulfillment_orders: list,
    tracking_url: str = None,
    carrier: str = None
) -> Dict[str, Any]:
    """
    更新Shopify履约信息 - 基于订单的店铺信息
    
    Args:
        sales_order_name: ERPNext销售订单名称 (必需)
        tracking_number: 物流跟踪号 (必需)
        fulfillment_orders: FulfillmentOrder列表 (必需，严格格式校验)
        格式: [
            {
                "fulfillmentOrderId": "gid://shopify/FulfillmentOrder/123456789",
                "fulfillmentOrderLineItems": [
                    {
                        "id": "gid://shopify/FulfillmentOrderLineItem/987654321",
                        "quantity": 2
                    }
                ]
            }
        ]
        tracking_url: 物流跟踪链接 (可选)
        carrier: 物流公司名称 (可选)
    
    Returns:
        Dict: 更新结果
    """
    try:
        # 严格参数校验
        if not sales_order_name or not isinstance(sales_order_name, str) or not sales_order_name.strip():
            return {
                "success": False,
                "error": "Sales order name is required and must be a non-empty string"
            }
        
        if not tracking_number or not isinstance(tracking_number, str) or not tracking_number.strip():
            return {
                "success": False,
                "error": "Tracking number is required and must be a non-empty string"
            }
        
        # 从销售订单获取店铺信息
        try:
            sales_order = frappe.get_doc("Sales Order", sales_order_name)
            shop_name = getattr(sales_order, 'custom_shop_name', None)
            
            if not shop_name:
                return {
                    "success": False,
                    "error": f"Sales order {sales_order_name} does not have shop information (custom_shop_name field is empty)"
                }
                
            frappe.logger().info(f"[Shopify Fulfillment] 从订单获取店铺信息 - 订单: {sales_order_name}, 店铺: {shop_name}")
            
        except frappe.DoesNotExistError:
            return {
                "success": False,
                "error": f"Sales order {sales_order_name} not found"
            }
        except Exception as e:
            return {
                "success": False,
                "error": f"Failed to get shop information from sales order: {str(e)}"
            }
        
        if not fulfillment_orders or not isinstance(fulfillment_orders, list) or len(fulfillment_orders) == 0:
            return {
                "success": False,
                "error": "Fulfillment orders is required and must be a non-empty list"
            }
        
        # 校验可选参数
        if tracking_url is not None and (not isinstance(tracking_url, str) or not tracking_url.strip()):
            return {
                "success": False,
                "error": "Tracking URL must be a non-empty string if provided"
            }
        
        if carrier is not None and (not isinstance(carrier, str) or not carrier.strip()):
            return {
                "success": False,
                "error": "Carrier must be a non-empty string if provided"
            }
        
        # 简单校验 fulfillment_orders 格式
        for i, fulfillment_order in enumerate(fulfillment_orders):
            if not isinstance(fulfillment_order, dict):
                return {
                    "success": False,
                    "error": f"Fulfillment order at index {i} must be a dictionary"
                }
            
            # 只校验必要字段存在
            if "fulfillmentOrderId" not in fulfillment_order or "fulfillmentOrderLineItems" not in fulfillment_order:
                return {
                    "success": False,
                    "error": f"Fulfillment order at index {i} must have 'fulfillmentOrderId' and 'fulfillmentOrderLineItems' fields"
                }
            
            # 简单校验 line items 是数组且非空
            line_items = fulfillment_order["fulfillmentOrderLineItems"]
            if not isinstance(line_items, list) or len(line_items) == 0:
                return {
                    "success": False,
                    "error": f"Fulfillment order at index {i} 'fulfillmentOrderLineItems' must be a non-empty list"
                }
        
        
        # 记录操作日志
        frappe.logger().info(
            f"[Shopify Fulfillment] 开始更新履约信息 - 订单: {sales_order_name}, 店铺: {shop_name}, 跟踪号: {tracking_number}, 履约订单数: {len(fulfillment_orders)}"
        )
        
        # 创建Shopify客户端
        try:
            frappe.logger().info(f"[Shopify Fulfillment] 创建客户端 - 请求的shop_name: {shop_name}")
            client = ShopifyClient(shop_name)
            frappe.logger().info(f"[Shopify Fulfillment] 客户端创建成功 - 实际使用的shop: {client.config.name}, shopurl: {client.config.shopurl}")
        except ShopifyClientError as exc:
            frappe.logger().error(f"[Shopify Fulfillment] 客户端创建失败: {str(exc)}")
            return {
                "success": False,
                "error": f"Failed to create Shopify client: {str(exc)}"
            }
        
        # 构建跟踪信息
        tracking_info = {
            "number": tracking_number.strip(),
            "company": carrier.strip() if carrier else "Unknown"
        }
        
        if tracking_url and tracking_url.strip():
            tracking_info["url"] = tracking_url.strip()
        
        # 创建履约记录
        fulfillment_result = _create_fulfillment(
            client, 
            "unknown_order_id",  # 这个函数不需要order_id，因为fulfillment_orders已经包含了订单信息
            fulfillment_orders, 
            tracking_info
        )
        
        if fulfillment_result.get("success"):
            frappe.logger().info(
                f"[Shopify Fulfillment] 履约信息更新成功 - 履约ID: {fulfillment_result.get('fulfillment_id')}"
            )
            return {
                "success": True,
                "message": "Fulfillment updated successfully",
                "fulfillment_id": fulfillment_result.get("fulfillment_id"),
                "tracking_number": tracking_number,
                "carrier": carrier or "Unknown"
            }
        else:
            frappe.logger().error(
                f"[Shopify Fulfillment] 履约信息更新失败 - 错误: {fulfillment_result.get('error')}"
            )
            return {
                "success": False,
                "error": fulfillment_result.get("error", "Failed to create fulfillment")
            }
    
    except Exception as e:
        frappe.logger().error(f"[Shopify Fulfillment] 更新异常: {str(e)}")
        return {
            "success": False,
            "error": f"Failed to update fulfillment: {str(e)}"
        }


def build_fulfillment_orders_from_sales_order(sales_order_name: str) -> list:
    """
    从Sales Order Item中构建fulfillment orders数据
    避免每次都从Shopify API获取
    """
    try:
        sales_order = frappe.get_doc("Sales Order", sales_order_name)
        shopify_order_id = getattr(sales_order, 'custom_shopify_order_id', None)
        
        if not shopify_order_id:
            return []
        
        # 从Sales Order Item中获取Shopify line item ID
        fulfillment_orders = []
        for item in sales_order.items:
            shopify_line_item_id = getattr(item, 'custom_shopify_line_item_id', None)
            if shopify_line_item_id:
                fulfillment_orders.append({
                    "fulfillmentOrderId": shopify_order_id,  # 这里需要fulfillment order ID，不是order ID
                    "fulfillmentOrderLineItems": [{
                        "id": shopify_line_item_id,
                        "quantity": item.qty
                    }]
                })
        
        return fulfillment_orders
        
    except Exception as e:
        frappe.logger().error(f"[Shopify Fulfillment] 从Sales Order构建fulfillment数据失败: {str(e)}")
        return []


def _create_fulfillment(
    client: ShopifyClient, 
    order_id: str, 
    line_items: list, 
    tracking_info: dict
) -> Dict[str, Any]:
    """
    创建Shopify履约记录
    """
    try:
        mutation = """
        mutation FulfillmentCreateV2($fulfillment: FulfillmentV2Input!) {
            fulfillmentCreateV2(fulfillment: $fulfillment) {
                fulfillment {
                    id
                    status
                }
                userErrors {
                    field
                    message
                }
            }
        }
        """
        
        # 直接使用传入的 line_items 数据
        variables = {
            "fulfillment": {
                "lineItemsByFulfillmentOrder": line_items,
                "trackingInfo": tracking_info,
                "notifyCustomer": True
            }
        }
        
        result = client._post_graphql(mutation, variables)
        
        if not result:
            return {
                "success": False,
                "error": "No response from Shopify API"
            }
        
        # client._post_graphql 已返回 data 字段的内容
        fulfillment_create = result.get("fulfillmentCreateV2")
        if not fulfillment_create:
            return {
                "success": False,
                "error": f"No fulfillmentCreateV2 in response: {result}"
            }
        
        user_errors = fulfillment_create.get("userErrors", [])
        
        if user_errors:
            error_messages = [error.get("message", "Unknown error") for error in user_errors]
            return {
                "success": False,
                "error": f"Shopify API errors: {'; '.join(error_messages)}"
            }
        
        fulfillment = fulfillment_create.get("fulfillment", {})
        
        return {
            "success": True,
            "fulfillment_id": fulfillment.get("id"),
            "status": fulfillment.get("status"),
            "tracking_info": fulfillment.get("trackingInfo", {})
        }
        
    except Exception as e:
        frappe.logger().error(f"[Shopify Fulfillment] 创建履约记录失败: {str(e)}")
        return {
            "success": False,
            "error": f"Failed to create fulfillment: {str(e)}"
        }


@frappe.whitelist()
def get_shopify_order_fulfillments(sales_order_name: str) -> Dict[str, Any]:
    """
    根据ERPNext销售订单名称查询Shopify履约信息 - 用于前端人工核验
    Args:
        sales_order_name: ERPNext销售订单名称
    
    Returns:
        Dict: 订单履约信息
    """
    try:
        # 参数校验
        if not sales_order_name or not isinstance(sales_order_name, str) or not sales_order_name.strip():
            return {
                "success": False,
                "error": "Sales order name is required and must be a non-empty string"
            }
        
        # 从销售订单获取店铺信息和Shopify订单ID
        try:
            sales_order = frappe.get_doc("Sales Order", sales_order_name)
            shop_name = getattr(sales_order, 'custom_shop_name', None)
            shopify_order_id = getattr(sales_order, 'custom_shopify_order_id', None)
            
            if not shop_name:
                return {
                    "success": False,
                    "error": f"Sales order {sales_order_name} does not have shop information (custom_shop_name field is empty)"
                }
            
            if not shopify_order_id:
                return {
                    "success": False,
                    "error": f"Sales order {sales_order_name} does not have Shopify order ID (custom_shopify_order_id field is empty)"
                }
                
            frappe.logger().info(f"[Shopify Fulfillment] 从订单获取信息 - 订单: {sales_order_name}, 店铺: {shop_name}, Shopify订单: {shopify_order_id}")
            
        except frappe.DoesNotExistError:
            return {
                "success": False,
                "error": f"Sales order {sales_order_name} not found"
            }
        except Exception as e:
            return {
                "success": False,
                "error": f"Failed to get information from sales order: {str(e)}"
            }
        
        # 标准化Shopify订单ID格式
        shopify_order_id = shopify_order_id.strip()
        if not shopify_order_id.startswith("gid://shopify/Order/"):
            if shopify_order_id.isdigit():
                shopify_order_id = f"gid://shopify/Order/{shopify_order_id}"
            else:
                return {
                    "success": False,
                    "error": "Invalid Shopify order ID format. Must be numeric ID or gid://shopify/Order/ format"
                }
        
        # 记录操作日志
        frappe.logger().info(
            f"[Shopify Fulfillment] 查询订单履约信息 - 订单: {sales_order_name}, 店铺: {shop_name}, Shopify订单: {shopify_order_id}"
        )
        
        # 创建Shopify客户端
        try:
            frappe.logger().info(f"[Shopify Fulfillment] 创建客户端 - 请求的shop_name: {shop_name}")
            client = ShopifyClient(shop_name)
            frappe.logger().info(f"[Shopify Fulfillment] 客户端创建成功 - 实际使用的shop: {client.config.name}, shopurl: {client.config.shopurl}")
        except ShopifyClientError as exc:
            frappe.logger().error(f"[Shopify Fulfillment] 客户端创建失败: {str(exc)}")
            return {
                "success": False,
                "error": f"Failed to create Shopify client: {str(exc)}"
            }
        
        # 查询订单履约信息
        fulfillment_data = _get_order_fulfillments(client, shopify_order_id)
        
        if not fulfillment_data:
            return {
                "success": False,
                "error": "Failed to retrieve fulfillment data"
            }
        
        return {
            "success": True,
            "data": fulfillment_data,
            "message": "Fulfillment data retrieved successfully"
        }
    
    except Exception as e:
        frappe.logger().error(f"[Shopify Fulfillment] 查询履约信息异常: {str(e)}")
        return {
            "success": False,
            "error": f"Failed to get fulfillment data: {str(e)}"
        }


def _get_order_fulfillments(client: ShopifyClient, order_id: str) -> Dict[str, Any]:
    """
    查询订单的履约信息
    """
    try:
        query = """
        query GetOrderFulfillments($orderId: ID!) {
            order(id: $orderId) {
                id
                name
                displayFinancialStatus
                displayFulfillmentStatus
                fulfillments(first: 10) {
                    id
                    status
                    createdAt
                    updatedAt
                    trackingInfo {
                        number
                        url
                        company
                    }
                    fulfillmentLineItems(first: 50) {
                        edges {
                            node {
                                id
                                quantity
                                lineItem {
                                    id
                                    title
                                    variant {
                                        id
                                        sku
                                    }
                                }
                            }
                        }
                    }
                }
                fulfillmentOrders(first: 10) {
                    edges {
                        node {
                            id
                            status
                            assignedLocation {
                                name
                            }
                            destination {
                                id
                                address1
                                city
                                zip
                            }
                            lineItems(first: 50) {
                                edges {
                                    node {
                                        id
                                        remainingQuantity
                                        totalQuantity
                                        lineItem {
                                            id
                                            title
                                            variant {
                                                id
                                                sku
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
        """
        
        result = client._post_graphql(query, {"orderId": order_id})
                
        if not result or "order" not in result:
            frappe.logger().error(f"[Shopify Fulfillment] 订单查询失败: {order_id}")
            return None
        
        order = result["order"]
        
        # 处理履约记录
        fulfillments = []
        for fulfillment in order.get("fulfillments", []):
            fulfillment_line_items = []
            
            for line_edge in fulfillment.get("fulfillmentLineItems", {}).get("edges", []):
                line_item = line_edge["node"]
                fulfillment_line_items.append({
                    "id": line_item["id"],
                    "quantity": line_item["quantity"],
                    "line_item": {
                        "id": line_item["lineItem"]["id"],
                        "title": line_item["lineItem"]["title"],
                        "variant": {
                            "id": line_item["lineItem"]["variant"]["id"],
                            "sku": line_item["lineItem"]["variant"]["sku"]
                        }
                    }
                })
            
            fulfillments.append({
                "id": fulfillment["id"],
                "status": fulfillment["status"],
                "created_at": fulfillment["createdAt"],
                "updated_at": fulfillment["updatedAt"],
                # Normalize trackingInfo: Shopify returns a list; UI expects a single object
                "tracking_info": (
                    (fulfillment.get("trackingInfo") or [{}])[0]
                    if isinstance(fulfillment.get("trackingInfo"), list)
                    else (fulfillment.get("trackingInfo") or {})
                ),
                "line_items": fulfillment_line_items
            })
        
        # 处理履约订单
        fulfillment_orders = []
        for edge in order.get("fulfillmentOrders", {}).get("edges", []):
            fulfillment_order = edge["node"]
            fulfillment_order_line_items = []
            
            for line_edge in fulfillment_order.get("lineItems", {}).get("edges", []):
                line_node = line_edge["node"]
                fulfillment_order_line_items.append({
                    "id": line_node["id"],
                    "remaining_quantity": line_node["remainingQuantity"],
                    "total_quantity": line_node["totalQuantity"],
                    "line_item": {
                        "id": line_node["lineItem"]["id"],
                        "title": line_node["lineItem"]["title"],
                        "variant": {
                            "id": line_node["lineItem"]["variant"]["id"],
                            "sku": line_node["lineItem"]["variant"]["sku"]
                        }
                    }
                })
            
            fulfillment_orders.append({
                "id": fulfillment_order["id"],
                "status": fulfillment_order["status"],
                "assigned_location": fulfillment_order.get("assignedLocation"),
                "destination": fulfillment_order.get("destination"),
                "line_items": fulfillment_order_line_items
            })
        
        return {
            "order": {
                "id": order["id"],
                "name": order["name"],
                "financial_status": order["displayFinancialStatus"],
                "fulfillment_status": order["displayFulfillmentStatus"]
            },
            "fulfillments": fulfillments,
            "fulfillment_orders": fulfillment_orders
        }
        
    except Exception as e:
        frappe.logger().error(f"[Shopify Fulfillment] 查询履约信息失败: {str(e)}")
        frappe.logger().error(f"[Shopify Fulfillment] 异常详情: {type(e).__name__}: {str(e)}")
        import traceback
        frappe.logger().error(f"[Shopify Fulfillment] 堆栈跟踪: {traceback.format_exc()}")
        return None


@frappe.whitelist()
def update_shopify_fulfillment_tracking(
    sales_order_name: str,
    tracking_number: str,
    tracking_url: str = None,
    carrier: str = None
) -> Dict[str, Any]:
    """
    更新Shopify履约的物流跟踪信息
    
    Args:
        sales_order_name: ERPNext销售订单名称
        tracking_number: 新的物流跟踪号
        tracking_url: 物流跟踪链接
        carrier: 物流公司名称
    
    Returns:
        Dict: 更新结果
    """
    # 防止在异常分支引用未定义变量
    shop_name = None
    try:
        # 参数校验
        if not sales_order_name or not isinstance(sales_order_name, str) or not sales_order_name.strip():
            return {
                "success": False,
                "error": "Sales order name is required and must be a non-empty string"
            }
        
        # 从销售订单获取店铺信息和Shopify订单ID
        try:
            sales_order = frappe.get_doc("Sales Order", sales_order_name)
            shop_name = getattr(sales_order, 'custom_shop_name', None)
            shopify_order_id = getattr(sales_order, 'custom_shopify_order_id', None)
            
            if not shop_name:
                return {
                    "success": False,
                    "error": f"Sales order {sales_order_name} does not have shop information (custom_shop_name field is empty)"
                }
            
            if not shopify_order_id:
                return {
                    "success": False,
                    "error": f"Sales order {sales_order_name} does not have Shopify order ID (custom_shopify_order_id field is empty)"
                }
                
            frappe.logger().info(f"[Shopify Fulfillment] 从订单获取信息 - 订单: {sales_order_name}, 店铺: {shop_name}, Shopify订单: {shopify_order_id}")
            
        except frappe.DoesNotExistError:
            return {
                "success": False,
                "error": f"Sales order {sales_order_name} not found"
            }
        except Exception as e:
            return {
                "success": False,
                "error": f"Failed to get information from sales order: {str(e)}"
            }
        
        if not tracking_number or not isinstance(tracking_number, str) or not tracking_number.strip():
            return {
                "success": False,
                "error": "Tracking number is required and must be a non-empty string"
            }
        
        # 标准化Shopify订单ID格式
        shopify_order_id = shopify_order_id.strip()
        if not shopify_order_id.startswith("gid://shopify/Order/"):
            if shopify_order_id.isdigit():
                shopify_order_id = f"gid://shopify/Order/{shopify_order_id}"
            else:
                return {
                    "success": False,
                    "error": "Invalid Shopify order ID format. Must be numeric ID or gid://shopify/Order/ format"
                }
        
        # 记录操作日志
        frappe.logger().info(
            f"[Shopify Fulfillment] 更新物流跟踪信息 - 店铺: {shop_name}, 订单: {shopify_order_id}, 跟踪号: {tracking_number}"
        )
        
        # 创建Shopify客户端
        try:
            client = ShopifyClient(shop_name)
        except ShopifyClientError as exc:
            frappe.logger().error(f"[Shopify Fulfillment] 客户端创建失败: {str(exc)}")
            return {
                "success": False,
                "error": f"Failed to create Shopify client: {str(exc)}"
            }
        
        # 更新履约跟踪信息
        # 统一调用 api_shop 实现，避免双份逻辑漂移
        try:
            update_result = shop_api._update_fulfillment_tracking(client, shopify_order_id, tracking_number, tracking_url, carrier)
        except TypeError:
            # 兼容老签名或环境差异，回退到本地实现
            update_result = _update_fulfillment_tracking(client, shop_name, shopify_order_id, tracking_number, tracking_url, carrier)
        
        if update_result.get("success"):
            frappe.logger().info(
                f"[Shopify Fulfillment] 物流跟踪信息更新成功 - 订单: {shopify_order_id}, 跟踪号: {tracking_number}"
            )
            _notify_task(
                subject="Shopify履约更新成功",
                message=f"tracking={tracking_number}",
                shop_name=shop_name,
                sales_order_name=sales_order_name,
            )
            return {
                "success": True,
                "message": "Fulfillment tracking updated successfully",
                "tracking_number": tracking_number,
                "carrier": carrier or "Unknown"
            }
        else:
            frappe.logger().error(
                f"[Shopify Fulfillment] 物流跟踪信息更新失败 - 订单: {shopify_order_id}, 错误: {update_result.get('error')}"
            )
            _notify_task(
                subject="Shopify履约更新失败",
                message=str(update_result.get('error')),
                shop_name=shop_name,
                level="error",
                sales_order_name=sales_order_name,
            )
            return {
                "success": False,
                "error": update_result.get("error", "Failed to update fulfillment tracking")
            }
    
    except Exception as e:
        frappe.logger().error(f"[Shopify Fulfillment] 更新物流跟踪信息异常: {str(e)}")
        _notify_task(
            subject="Shopify履约更新异常",
            message=str(e),
            shop_name=shop_name,
            level="error",
            sales_order_name=sales_order_name,
        )
        return {
            "success": False,
            "error": f"Failed to update fulfillment tracking: {str(e)}"
        }


def _update_fulfillment_tracking(*args, **kwargs):  # deprecated shim
    return shop_api._update_fulfillment_tracking(*args, **kwargs)


def _get_target_location_names(shop_name: str) -> set:
    """Return configured Shopify location names from usershop.allowed_warehouses.
    Mandatory: if missing/invalid/empty, raise ValidationError to stop the flow.
    """
    try:
        shop_doc = frappe.get_doc("usershop", shop_name)
    except Exception:
        frappe.throw(msg=f"未找到店铺配置(usershop): {shop_name}", exc=frappe.ValidationError)

    raw = getattr(shop_doc, "allowed_warehouses", None)
    if not raw:
        frappe.throw(msg=f"店铺 {shop_name} 未配置 allowed_warehouses（JSON 数组），无法执行履约发货。",
                     exc=frappe.ValidationError)

    # If the field is already a list (DocField of type JSON), accept directly
    if isinstance(raw, (list, tuple)):
        result = {str(x).strip() for x in raw if str(x).strip()}
        if not result:
            frappe.throw(msg=f"店铺 {shop_name} 的 allowed_warehouses 为空，无法执行履约发货。",
                         exc=frappe.ValidationError)
        return result

    # If stored as string, only accept valid JSON array
    try:
        parsed = json.loads(raw) if isinstance(raw, str) else []
        if isinstance(parsed, list):
            result = {str(x).strip() for x in parsed if str(x).strip()}
            if not result:
                frappe.throw(msg=f"店铺 {shop_name} 的 allowed_warehouses 为空，无法执行履约发货。",
                             exc=frappe.ValidationError)
            return result
        frappe.throw(msg=f"店铺 {shop_name} 的 allowed_warehouses 不是有效的 JSON 数组。",
                     exc=frappe.ValidationError)
    except Exception:
        frappe.throw(msg=f"店铺 {shop_name} 的 allowed_warehouses 解析失败（需为 JSON 数组）。",
                     exc=frappe.ValidationError)


def _get_fulfillment_locations(order: Dict) -> set:
    """Collect all assigned location names from fulfillmentOrders of an order."""
    locations: set = set()
    edges = (order.get("fulfillmentOrders") or {}).get("edges", [])
    for edge in edges:
        node = edge.get("node", {})
        assigned = node.get("assignedLocation") or {}
        name = assigned.get("name")
        if name:
            locations.add(str(name))
    return locations


def _get_line_item_location_map(order: Dict) -> Dict[str, str]:
    """Return a mapping from Shopify LineItem GID to assigned location name.
    Reads fulfillmentOrders->lineItems and builds a dict {lineItem.id: assignedLocation.name}.
    """
    mapping: Dict[str, str] = {}
    for edge in (order.get("fulfillmentOrders") or {}).get("edges", []):
        node = edge.get("node", {})
        loc = ((node.get("assignedLocation") or {}).get("name"))
        if not loc:
            continue
        for li_edge in (node.get("lineItems") or {}).get("edges", []):
            li_node = li_edge.get("node", {})
            line_item_id = ((li_node.get("lineItem") or {}).get("id"))
            if line_item_id:
                mapping[str(line_item_id)] = str(loc)
    return mapping


def _parse_time_hhmm(value: Optional[str]) -> Optional[tuple[int, int]]:
    """Parse simple HH:MM or HH:MM:SS string into (hour, minute).
    Returns None on invalid/empty input.
    """
    if not value:
        return None
    try:
        s = str(value).strip()
        if not s:
            return None
        parts = s.split(":")
        if len(parts) < 2:
            return None
        h = int(parts[0])
        m = int(parts[1])
        if h < 0 or h > 23 or m < 0 or m > 59:
            return None
        return h, m
    except Exception:
        return None


def _is_within_shop_schedule(shop_name: str) -> bool:
    """Return True if current time is within usershop.begin_time ~ end_time.
    Rules:
    - If both times missing/invalid -> allow (True)
    - If begin <= end: begin <= now <= end
    - If begin > end (overnight): now >= begin or now <= end
    """
    try:
        shop = frappe.get_doc("usershop", shop_name)
    except Exception:
        return True

    begin_raw = getattr(shop, "begin_time", None)
    end_raw = getattr(shop, "end_time", None)
    bt = _parse_time_hhmm(begin_raw)
    et = _parse_time_hhmm(end_raw)
    if not bt or not et:
        return True

    now_t = now_datetime().time()
    bh, bm = bt
    eh, em = et

    begin_ok = (now_t.hour > bh) or (now_t.hour == bh and now_t.minute >= bm)
    end_ok = (now_t.hour < eh) or (now_t.hour == eh and now_t.minute <= em)

    if (bh, bm) <= (eh, em):
        # normal range
        return begin_ok and end_ok
    # overnight range
    return begin_ok or end_ok


@frappe.whitelist()
def scheduled_sync_shopify_orders_for_shop(shop_name: str,
                                           batch_size: int = DEFAULT_ORDER_BATCH_SIZE,
                                           since: Optional[str] = None,
                                           max_pages: int = DEFAULT_ORDER_MAX_PAGES) -> Dict[str, object]:
    """Scheduler-friendly entry: sync orders for a specific usershop.
    Intended to be referenced in hooks scheduler_events with a fixed shop_name.
    Uses file-based checkpoint per shop.
    """
    if not shop_name or not isinstance(shop_name, str) or not shop_name.strip():
        return {"success": False, "message": "shop_name is required"}

    try:
        frappe.logger().error(
            f"[Scheduler][ForShop][START] shop={shop_name} batch_size={batch_size} since={since} max_pages={max_pages}"
        )
    except Exception:
        pass

    # Business-hours gate: skip processing completely if outside time window
    try:
        within = _is_within_shop_schedule(shop_name)
    except Exception:
        within = True
    if not within:
        return {
            "success": True,
            "message": "Skipped by business-hours window",
            "skipped_by_schedule": True,
        }

    task = "orders_sync"
    ckpt = _load_checkpoint(task, shop_name)
    cursor_from_ckpt = ckpt.get("cursor")
    since_from_ckpt = ckpt.get("since")

    # apply drift to since (防抖回退)
    if since_from_ckpt:
        try:
            base_dt = get_datetime(since_from_ckpt)
            since_from_ckpt = (base_dt - timedelta(seconds=CHECKPOINT_SINCE_DRIFT_SECONDS)).strftime("%Y-%m-%dT%H:%M:%SZ")
        except Exception:
            pass

    shop_cfg = None
    # First-run override: only when is_start_from_init 为真时，才使用 query_days_before 回溯
    try:
        shop_cfg = frappe.get_doc("usershop", shop_name)
        first_run_days_raw = getattr(shop_cfg, "query_days_before", None)
        first_run_days = cint(first_run_days_raw) if first_run_days_raw not in (None, "") else 0
        first_run_days = max(first_run_days, 0)
        backfill_first_run = _coerce_check(getattr(shop_cfg, "is_start_from_init", 0))
    except Exception:
        first_run_days = 0
        backfill_first_run = False

    # Determine base effective_since
    effective_since = since
    if backfill_first_run:
        days = first_run_days if first_run_days > 0 else 30
        effective_since = (now_datetime() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
        try:
            frappe.logger().error(
                f"[Scheduler][ForShop] first_run init days={days} since={effective_since}"
            )
        except Exception:
            pass
        _clear_checkpoint(task, shop_name)
        cursor_from_ckpt = None
    elif not effective_since:
        if since_from_ckpt:
            effective_since = since_from_ckpt
        else:
            effective_since = (now_datetime() - timedelta(seconds=CHECKPOINT_SINCE_DRIFT_SECONDS)).strftime("%Y-%m-%dT%H:%M:%SZ")
            cursor_from_ckpt = None
            try:
                frappe.logger().error(
                    f"[Scheduler][ForShop] no checkpoint, use current window since={effective_since}"
                )
            except Exception:
                pass

    # First-run backfill override
    if backfill_first_run and shop_cfg:
        try:
            shop_cfg.db_set("is_start_from_init", 0, update_modified=False)
        except Exception:
            pass

    frappe.logger().error(
        f"[Scheduler][ForShop] begin shop={shop_name} cursor={cursor_from_ckpt} since={effective_since}"
    )
    result = sync_shopify_orders(
        shop_name=shop_name.strip(),
        batch_size=batch_size,
        cursor=cursor_from_ckpt,
        since=effective_since,
        max_pages=max_pages,
    )

    # 站内通知：任务结果
    try:
        _notify_task(
            subject="Shopify订单同步完成",
            message=result.get("message") or "",
            shop_name=shop_name,
            level=("info" if result.get("success") else "error"),
        )
    except Exception:
        pass

    # write checkpoint：仅在同步成功时推进游标；失败时保持原游标以便重试
    if result.get("success"):
        new_ckpt = {
            "since": effective_since,
            "last_run_at": now_datetime().strftime("%Y-%m-%dT%H:%M:%SZ"),
        }
        if result.get("has_next"):
            new_ckpt["cursor"] = result.get("next_cursor")
        else:
            new_ckpt["cursor"] = None
            # 使用当前批处理窗口尾端（now - drift）作为下一次 since 的起点
            end_dt = now_datetime() - timedelta(seconds=CHECKPOINT_SINCE_DRIFT_SECONDS)
            new_ckpt["since"] = end_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
        _save_checkpoint(task, shop_name, new_ckpt)

    try:
        frappe.logger().error(
            f"[Scheduler][ForShop][END] shop={shop_name} success={result.get('success')} fetched={result.get('fetched')} created={result.get('created')} updated={result.get('updated')} skipped_wh={result.get('skipped_warehouse')} skipped_unpaid={result.get('skipped_unpaid')} skipped_fulfilled={result.get('skipped_fulfilled')} has_next={result.get('has_next')} next_cursor={result.get('next_cursor')}"
        )
    except Exception:
        pass
    frappe.logger().info(f"[Scheduler] finished shopify order sync for shop={shop_name}: {result}")
    return result


def scheduled_sync_shopify_orders_all_enabled():
    """Optional: iterate shops (e.g., add a custom toggle on usershop later).
    If no toggle, runs for all usershop records.
    """
    shops = frappe.get_all("usershop", pluck="name")
    for name in shops:
        try:
            scheduled_sync_shopify_orders_for_shop(name)
        except Exception as e:
            frappe.logger().error(f"[Scheduler] sync failed for shop={name}: {e}")


def scheduled_sync_shopify_orders_by_cycle():
    """Iterate all shops and run sync based on per-shop cycle minutes.
    usershop.query_cycle_num: Int (minutes). 0 => disabled. >0 => run only if
    last_run_at older than cycle window (by checkpoint 'orders_sync').
    """
    try:
        shops = frappe.get_all("usershop", fields=["name", "query_cycle_num"], limit_page_length=0)
        try:
            frappe.logger().error(f"[Scheduler][Cycle] shops_count={len(shops)}")
        except Exception:
            pass
    except Exception as e:
        frappe.logger().error(f"[Scheduler] failed to list usershop: {e}")
        return

    now_dt = now_datetime()
    for shop in shops:
        name = shop.get("name")
        cycle_min = cint(shop.get("query_cycle_num") or 0)
        if cycle_min <= 0:
            try:
                frappe.logger().error(f"[Scheduler][Cycle] skip shop={name} cycle_min={cycle_min}")
            except Exception:
                pass
            continue

        ckpt = _load_checkpoint("orders_sync", name)
        last_run_at = ckpt.get("last_run_at")
        if last_run_at:
            should_run = False
            try:
                last_dt = get_datetime(last_run_at)
                delta_sec = (now_dt - last_dt).total_seconds()
                should_run = delta_sec >= cycle_min * 60
                try:
                    frappe.logger().error(
                        f"[Scheduler][Cycle] shop={name} cycle_min={cycle_min} last_run_at={last_run_at} delta_sec={delta_sec} should_run={should_run}"
                    )
                except Exception:
                    pass
            except Exception:
                should_run = True
        else:
            should_run = True

        if not should_run:
            try:
                frappe.logger().error(f"[Scheduler][Cycle] not due shop={name}")
            except Exception:
                pass
            continue

        try:
            frappe.logger().error(f"[Scheduler][Cycle] run shop={name} cycle_min={cycle_min}")
            scheduled_sync_shopify_orders_for_shop(name)
        except Exception as e:
            frappe.logger().error(f"[Scheduler] cycle-run failed for shop={name}: {e}")


@frappe.whitelist()
def trigger_scheduled_sync_shopify_orders_by_cycle() -> None:
    """Whitelisted wrapper for Server Script safe_exec.
    Server Script sandbox无法使用 frappe.get_attr，使用 frappe.call 需要白名单方法。
    """
    scheduled_sync_shopify_orders_by_cycle()


def import_sales_order_from_payload(
    *, shop_name: str, order: Dict, warehouse: str, auto_dispatch: bool, allowed_locations: set, company: Optional[str] = None, customer_name: Optional[str] = None, preloaded_rules: Optional[list[dict]] = None
) -> str:
    """Create a Sales Order directly from a Shopify order payload (no staging).
    Returns the Sales Order name on success.
    """
    if not order:
        raise frappe.ValidationError("Shopify order payload is empty")

    payload = order

    line_items = (payload.get("lineItems") or {}).get("nodes") or []
    if not line_items:
        raise frappe.ValidationError("Shopify order has no line items")

    missing_skus: list[str] = []
    item_rows: list[Dict] = []

    # 仅导入我们能发货的行：根据 FO 地点过滤
    location_map = _get_line_item_location_map(payload)

    filtered_lines = []
    has_excluded = False
    for line in line_items:
        line_id = line.get("id")
        loc_name = location_map.get(str(line_id))
        if loc_name and (loc_name in allowed_locations):
            filtered_lines.append(line)
        else:
            has_excluded = True

    # 3) 如果所有明细都被排除，则跳过整单；若部分被排除，则标记多仓
    if not filtered_lines:
        return ""

    # 仅解析保留的行
    sku_set = set()
    for line in filtered_lines:
        sku_val = line.get("sku")
        if sku_val is None:
            missing_skus.append(f"Line without SKU (null): {line.get('name')}")
            continue
        sku_str = str(sku_val).strip()
        if not sku_str:
            missing_skus.append(f"Line without SKU (empty): {line.get('name')}")
            continue
        sku_set.add(sku_str)

    item_code_to_uom: Dict[str, str] = {}
    if sku_set:
        items = frappe.get_all(
            "Item",
            filters={"item_code": ["in", list(sku_set)]},
            fields=["item_code", "stock_uom"],
            limit_page_length=0,
        )
        for it in items:
            item_code_to_uom[str(it.get("item_code"))] = it.get("stock_uom") or "Nos"

    for sku in sku_set:
        if sku not in item_code_to_uom:
            missing_skus.append(sku)

    for line in filtered_lines:
        sku_str = str(line.get("sku") or "").strip()
        if not sku_str or sku_str not in item_code_to_uom:
            continue
        qty = flt(line.get("quantity") or 0)
        if qty <= 0:
            missing_skus.append(f"SKU {sku_str} has non-positive quantity")
            continue
        item_rows.append(
            {
                "item_code": sku_str,
                "item_name": line.get("title") or line.get("name") or sku_str,
                "description": line.get("name") or line.get("title") or sku_str,
                "qty": qty,
                "uom": item_code_to_uom.get(sku_str) or "Nos",
                "custom_shopify_line_item_id": line.get("id"),
                "custom_shopify_variant_id": (line.get("variant", {}) or {}).get("id"),
                "custom_shopify_product_id": ((line.get("variant", {}) or {}).get("product", {}) or {}).get("id"),
            }
        )

    if missing_skus:
        raise MissingSKUError(frappe._("Missing or invalid SKUs: {0}").format(", ".join(missing_skus)))

    # company/customer_name 采用传入参数，未传则回退
    if not company:
        company = _get_order_company(shop_name)
    if not customer_name:
        customer_name = "Shopify Default Customer"

    # 确保客户存在（若不存在则自动创建），避免后续 set_missing_values 失败
    try:
        customer_name = _ensure_customer_exists(customer_name, company)
    except Exception as _e:
        frappe.logger().error(f"[Shopify][Orders] 自动创建默认客户失败: {str(_e)}")

    transaction_date = _coerce_datetime(payload.get("createdAt"))
    transaction_date = getdate(transaction_date) if transaction_date else getdate(now_datetime())

    currency = payload.get("currencyCode") or frappe.db.get_default("currency")
    if not currency:
        currency = frappe.db.get_value("Company", company, "default_currency")

    sales_order = frappe.new_doc("Sales Order")
    sales_order.flags.ignore_permissions = True
    sales_order.flags.shopify_payload = payload
    sales_order.company = company
    sales_order.customer = customer_name
    sales_order.currency = currency
    sales_order.conversion_rate = 1
    sales_order.transaction_date = transaction_date
    sales_order.delivery_date = transaction_date
    sales_order.po_no = payload.get("name") or payload.get("id")

    custom_docname = _sanitize_shopify_order_name(payload.get("name"))
    if custom_docname:
        sales_order.name = custom_docname
        sales_order.flags.name_set = True

    shipping_address: dict[str, object] = payload.get("shippingAddress") or {}
    destination_country_name = (shipping_address.get("country") or "").strip()

    if destination_country_name:
        _set_optional_field(sales_order, "custom_country", destination_country_name)

    remarks_parts: list[str] = []
    if payload.get("note"):
        remarks_parts.append(str(payload.get("note")).strip())
    if payload.get("merchantEditable"):
        remarks_parts.append(str(payload.get("merchantEditable")).strip())
    if payload.get("additionalNote"):
        remarks_parts.append(str(payload.get("additionalNote")).strip())
    sales_order.remarks = "\n".join(filter(None, remarks_parts)) if remarks_parts else None

    # 通过拣货规则解析仓库/产品/模式（优先规则，不回退系统默认）
    rules_in_mem = preloaded_rules if isinstance(preloaded_rules, list) else _get_pick_rules_for_shop(shop_name)
    resolved_wh, resolved_product, pick_mode = _select_pick_rule_for_order_from_rules(shop_name, payload, rules_in_mem)
    if not resolved_wh:
        raise frappe.ValidationError("未在拣货规则中解析到仓库(warhouse)，请在 shop pick rule-table 勾选 is Active 并配置 warhouse")
    if not resolved_product:
        raise frappe.ValidationError("未在拣货规则中解析到物流产品(logistick_product)，请在 shop pick rule-table 配置 logistick_product 并勾选 is Active")

    # 若规则返回仓库，则写入单据级仓库（如果 Sales Order 有该字段）
    try:
        if resolved_wh and hasattr(sales_order, "set_warehouse"):
            sales_order.set_warehouse = resolved_wh
    except Exception:
        pass

    target_warehouse_field = _get_items_warehouse_field(sales_order)

    for row in item_rows:
        item = sales_order.append("items", {})
        item.item_code = row["item_code"]
        item.item_name = row["item_name"]
        item.description = row["description"]
        item.qty = row["qty"]
        if item.meta.has_field("uom"):
            item.uom = row.get("uom")
        # 写入定制化的 Shopify 明细字段（若存在对应自定义字段）
        if item.meta.has_field("custom_shopify_line_item_id"):
            item.custom_shopify_line_item_id = row.get("custom_shopify_line_item_id")
        if item.meta.has_field("custom_shopify_variant_id"):
            item.custom_shopify_variant_id = row.get("custom_shopify_variant_id")
        if item.meta.has_field("custom_shopify_product_id"):
            item.custom_shopify_product_id = row.get("custom_shopify_product_id")
        if target_warehouse_field and hasattr(item, target_warehouse_field):
            item.set(target_warehouse_field, (resolved_wh or warehouse))

    sales_order.set_missing_values()
    sales_order.calculate_taxes_and_totals()
    sales_order.status = "To Deliver" if auto_dispatch else "Draft"

    shopify_order_id = payload.get("id")
    _set_optional_field(sales_order, "custom_shopify_order_id", shopify_order_id)
    _set_optional_field(sales_order, "custom_shop_raw_data", json.dumps(payload, ensure_ascii=False))
    _set_optional_field(sales_order, "custom_shop_name", shop_name)

    # 多仓标志：按行过滤是否存在被排除行判断
    try:
        _set_optional_field(sales_order, "custom_has_multi_warehouse", 1 if has_excluded else 0)
    except Exception:
        pass

    # 将规则中的物流产品持久化（若有对应自定义字段）
    try:
        if resolved_product:
            parsed_code, parsed_name = _parse_logistics_product(resolved_product)
            _set_optional_field(sales_order, "custom_yt_product_code", parsed_code or resolved_product)
            _set_optional_field(sales_order, "custom_yt_product_name", parsed_name)
    except Exception:
        pass

    # 若分拣规则指定手动模式，则覆盖自动发货开关
    if pick_mode and str(pick_mode).lower().startswith("manual"):
        auto_dispatch = False

    if auto_dispatch:
        if sales_order.docstatus == 0:
            sales_order.submit()
        # 记录物流产品到评论
        if resolved_product:
            try:
                sales_order.add_comment("Info", f"物流产品: {resolved_product}")
            except Exception:
                pass
            # 用 flags 传递物流产品代码给发货流程（内存态，不落库）
            try:
                if not hasattr(sales_order, "flags"):
                    sales_order.flags = _dict()
                sales_order.flags.yt_product_code = resolved_product
            except Exception:
                pass

        # 创建云途/旺店通发货（内部会写 custom_delivery_no 等字段）
        delivery_payload = sync_order_wdt_yt(sales_order)
        # 推送物流信息到 Shopify 履约
        try:
            push_shopify_tracking_from_yunexpress(sales_order.name)
        except Exception:
            frappe.logger().error(
                f"[Shopify Fulfillment] 自动发货后推送履约信息失败: {frappe.get_traceback()}"
            )

    else:
        sales_order.save()

    return sales_order.name


def _checkpoint_path(task_name: str, shop_name: str) -> str:
    """Return per-task per-shop checkpoint file path under site private directory."""
    base_dir = frappe.get_site_path("private", "shopify_checkpoints")
    try:
        # ensure directory exists
        if not os.path.exists(base_dir):
            os.makedirs(base_dir, exist_ok=True)
    except Exception:
        pass
    filename = f"{task_name}__{shop_name}.json"
    return os.path.join(base_dir, filename)


def _clear_checkpoint(task_name: str, shop_name: str) -> None:
    """Delete checkpoint file if exists (best-effort)."""
    path = _checkpoint_path(task_name, shop_name)
    try:
        if os.path.exists(path):
            os.remove(path)
    except Exception:
        pass


def _coerce_check(value) -> bool:
    """Coerce Frappe Check field value to python bool.
    Frappe stores Check as 0/1 or '0'/'1'.
    """
    return True if value in (1, "1", True) else False

def _load_checkpoint(task_name: str, shop_name: str) -> Dict[str, object]:
    path = _checkpoint_path(task_name, shop_name)
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f) or {}
    except FileNotFoundError:
        return {}
    except Exception:
        return {}


def _save_checkpoint(task_name: str, shop_name: str, data: Dict[str, object]) -> None:
    path = _checkpoint_path(task_name, shop_name)
    try:
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data or {}, f, ensure_ascii=False, indent=2)
    except Exception:
        pass


@frappe.whitelist()
def push_shopify_tracking_from_yunexpress(sales_order_name: str) -> Dict[str, object]:
    """Push YunExpress tracking to Shopify for the given Sales Order.
    Uses Sales Order fields custom_shop_name, custom_shopify_order_id, custom_delivery_no, custom_label_url.
    """
    try:
        if not sales_order_name:
            return {"success": False, "error": "sales_order_name is required"}

        so = frappe.get_doc("Sales Order", sales_order_name)
        tracking_details = _resolve_shopify_tracking_details(so)
        tracking_number = _trim(tracking_details.get("tracking_number"))
        if not tracking_number:
            return {
                "success": False,
                "error": "Sales Order missing tracking number after applying logistics rules",
            }

        carrier = _trim(tracking_details.get("carrier")) or _get_default_tracking_carrier()
        tracking_url = _trim(tracking_details.get("tracking_url")) or None

        frappe.logger().error(
            "[Shopify Fulfillment] Resolved tracking for %s -> number=%s, carrier=%s, url=%s, product=%s, country=%s",
            sales_order_name,
            tracking_number,
            carrier,
            tracking_url or "N/A",
            tracking_details.get("logistics_product") or "-",
            tracking_details.get("country_code") or "-",
        )

        # Reuse existing flow that creates/updates Shopify fulfillment
        ret = update_shopify_fulfillment_tracking(
            sales_order_name=sales_order_name,
            tracking_number=str(tracking_number),
            tracking_url=str(tracking_url) if tracking_url else None,
            carrier=carrier,
        )
        _notify_task(
            subject=("Shopify履约推送成功" if ret.get("success") else "Shopify履约推送失败"),
            message=ret.get("message")
            or ret.get("error")
            or f"tracking={tracking_number}, carrier={carrier}",
            shop_name=getattr(so, "custom_shop_name", None),
            level=("info" if ret.get("success") else "error"),
            sales_order_name=sales_order_name,
        )
        return ret
    except Exception as e:
        frappe.logger().error(f"[Shopify Fulfillment] push from YunExpress failed: {e}\n{frappe.get_traceback()}")
        # Try best-effort to extract shop name from existing context
        try:
            shop_name = getattr(so, "custom_shop_name", None)  # type: ignore[name-defined]
        except Exception:
            shop_name = None
        _notify_task(
            subject="Shopify履约推送异常",
            message=str(e),
            shop_name=shop_name,
            level="error",
            sales_order_name=sales_order_name,
        )
        return {"success": False, "error": str(e)}


def _get_default_tracking_carrier() -> str:
    return DEFAULT_TRACKING_CARRIER


@frappe.whitelist()
def cancel_shopify_fulfillment(sales_order_name: str) -> Dict[str, Any]:
    """Cancel or delete Shopify fulfillment(s) for the given Sales Order.
    Strategy:
      1) Load Sales Order → shop_name + shopify_order_id
      2) Fetch fulfillment list
      3) For each fulfillment in 'open' or 'success' etc., try fulfillmentCancel mutation.
         If not cancellable, attempt fulfillmentDelete mutation as fallback.
    """
    try:
        if not sales_order_name or not isinstance(sales_order_name, str):
            return {"success": False, "error": "sales_order_name is required"}

        so = frappe.get_doc("Sales Order", sales_order_name)
        shop_name = getattr(so, "custom_shop_name", None)
        shopify_order_id = getattr(so, "custom_shopify_order_id", None)
        if not shop_name:
            return {"success": False, "error": "Sales order missing custom_shop_name"}
        if not shopify_order_id:
            return {"success": False, "error": "Sales order missing custom_shopify_order_id"}

        # normalize order id
        order_gid = shopify_order_id.strip()
        if not order_gid.startswith("gid://shopify/Order/"):
            if order_gid.isdigit():
                order_gid = f"gid://shopify/Order/{order_gid}"
            else:
                return {"success": False, "error": "Invalid Shopify order id format"}

        client = ShopifyClient(shop_name)

        # fetch current fulfillments
        data = _get_order_fulfillments(client, order_gid)
        if not data:
            return {"success": False, "error": "Failed to query fulfillments"}

        fulfillments = data.get("fulfillments", [])
        if not fulfillments:
            return {"success": True, "message": "No fulfillments to cancel"}

        cancelled = []
        failed = []
        skipped = []

        # mutation for cancel only
        cancel_mutation = """
        mutation FulfillmentCancel($id: ID!) {
          fulfillmentCancel(id: $id) {
            fulfillment { id status }
            userErrors { field message }
          }
        }
        """

        for f in fulfillments:
            fid = f.get("id")
            if not fid:
                continue

            try:
                res = client._post_graphql(cancel_mutation, {"id": fid})
                node = (res or {}).get("fulfillmentCancel") or ((res or {}).get("data") or {}).get("fulfillmentCancel")
                errs = (node or {}).get("userErrors") or []
                if node and not errs:
                    cancelled.append(fid)
                else:
                    failed.append({"id": fid, "error": errs or "Unknown error"})
            except Exception as e:
                failed.append({"id": fid, "error": str(e)})

        ok = len(failed) == 0
        return {
            "success": ok,
            "cancelled": cancelled,
            "failed": failed,
            "skipped": skipped,
            "message": f"cancelled={len(cancelled)} failed={len(failed)} skipped={len(skipped)}"
        }
    except Exception as e:
        frappe.logger().error(f"[Shopify Fulfillment] cancel error: {e}\n{frappe.get_traceback()}")
        return {"success": False, "error": str(e)}


def _notify_task(subject: str, message: str, *, shop_name: Optional[str] = None,
                 shopify_order_id: Optional[str] = None,
                 sales_order_name: Optional[str] = None,
                 level: str = "info") -> None:
    try:
        # 收件人兜底收集：兼容 Link 字段与 Table MultiSelect
        recipients: list[str] = []

        def _append_user(value):
            if not value:
                return
            uid = str(value).strip()
            if not uid:
                return
            # 过滤非系统用户
            try:
                enabled = frappe.db.get_value("User", uid, "enabled")
                if enabled in (1, True, "1"):
                    recipients.append(uid)
            except Exception:
                recipients.append(uid)

        if shop_name:
            try:
                shop_doc = frappe.get_doc("usershop", shop_name)
                # 依次尝试：notify_users(Table)、duty_users(Table)、user(Link)
                for fieldname in ("user"):
                    rows = getattr(shop_doc, fieldname, None)
                    if isinstance(rows, (list, tuple)) and rows:
                        for row in rows:
                            _append_user(getattr(row, "user", None) or getattr(row, "link_name", None))
                # 单值 Link
                if not recipients:
                    _append_user(getattr(shop_doc, "user", None))
            except Exception as e:
                frappe.logger().warning(f"[Notify] load usershop recipients failed: {e}")

        if not recipients:
            fallback = getattr(frappe.session, "user", None) or "Administrator"
            recipients = [fallback]

        parts = []
        if shop_name:
            parts.append(f"Shop: {shop_name}")
        if shopify_order_id:
            parts.append(f"Shopify Order: {shopify_order_id}")
        if sales_order_name:
            parts.append(f"Sales Order: {sales_order_name}")
        if message:
            parts.append(str(message))
        body = " | ".join(parts)
        payload = {
            "type": "Alert",
            "document_type": ("Sales Order" if sales_order_name else None),
            "document_name": (sales_order_name if sales_order_name else None),
            "subject": f"[{level.upper()}] {subject}",
            "email_content": body,
        }
        if not recipients:
            frappe.logger().warning("[Notify] No recipients resolved; skip sending")
            return
        _enqueue_notification(list(dict.fromkeys(recipients)), payload)
    except Exception:
        frappe.logger().error(f"[Notify] Failed to send in-app notification: {frappe.get_traceback()}")





